﻿// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Iot.Device.Common;
using Iot.Device.Nmea0183.Ais;
using Iot.Device.Nmea0183.AisSentences;
using Iot.Device.Nmea0183.Sentences;
using Microsoft.Extensions.Logging;
using UnitsNet;
using NavigationStatus = Iot.Device.Nmea0183.Ais.NavigationStatus;

namespace Iot.Device.Nmea0183
{
    /// <summary>
    /// Interpreter for AIS messages from NMEA-0183 data streams.
    /// Accepts the encoded AIVDM and AIVDO sentences and converts them to user-understandable ship structures.
    /// </summary>
    /// <remarks>
    /// WARNING: Never rely on an AIS alarm as sole supervision of your surroundings! Many ships do not have AIS or the system may malfunction.
    /// Keep a lookout by eye and ear at all times!
    /// </remarks>
    public class AisManager : NmeaSinkAndSource
    {
        /// <summary>
        /// Delegate for AIS messages
        /// </summary>
        /// <param name="received">True if the message was received from another ship, false if the message is generated internally (e.g. a proximity warning)</param>
        /// <param name="sourceMmsi">Source MMSI</param>
        /// <param name="destinationMmsi">Destination MMSI. May be 0 for a broadcast message</param>
        /// <param name="text">The text of the message.</param>
        public delegate void AisMessageHandler(bool received, uint sourceMmsi, uint destinationMmsi, string text);

        /// <summary>
        /// A delegate for use in the <see cref="AisManager.OnAisWarning"/> callback
        /// </summary>
        /// <param name="id">Message id. To identify the message type (e.g. for suppression)</param>
        /// <param name="sourceMmsi">The source MMSI. May be 0 in case of a warning by the own infrastructure</param>
        /// <param name="now">The current time</param>
        /// <param name="message">The message text</param>
        /// <param name="source">The source, if any</param>
        public delegate void AisWarning(AisMessageId id, uint sourceMmsi, DateTimeOffset now, string message,
            AisTarget? source);

        private readonly bool _throwOnUnknownMessage;

        private readonly AisParser _aisParser;

        /// <summary>
        /// We keep our own position cache, as we need to calculate CPA and TCPA values.
        /// The position provider can also be specified externally
        /// </summary>
        private readonly SentenceCache? _cache;

        /// <summary>
        /// Position provider, to get position data about the own ship
        /// </summary>
        private readonly PositionProvider _positionProvider;

        private readonly ConcurrentDictionary<uint, AisTarget> _targets;

        private readonly ConcurrentDictionary<AisMessageId, (string Message, DateTimeOffset TimeStamp)> _activeWarnings;

        private readonly object _lock;

        private DateTimeOffset? _lastCleanupCheck;

        /// <summary>
        /// This event fires when a new message (individual or broadcast) is received. Warnings generated by the component will also be fired here (in addition
        /// to being delivered with <see cref="OnAisWarning"/>.
        /// </summary>
        public event AisMessageHandler? OnMessage;

        /// <summary>
        /// This event is fired when an AIS warning occurs. Requires <see cref="EnableAisAlarms"/> to be set.
        /// Arguments are: MMSI of other vessel, current time, textual warning message and the reference to the other target (which includes further details)
        /// Messages are suppressed for the longer of <see cref="TrackEstimationParameters.WarningRepeatTimeout"/> or the specific vessels <see cref="AisTarget.SuppressionTime"/>
        /// </summary>
        public event AisWarning? OnAisWarning;

        private bool _aisAlarmsEnabled;

        private Thread? _aisBackgroundThread;

        /// <summary>
        /// This event fires after the ship relative positions have been updated (AIS alarms must be enabled)
        /// </summary>
        public event Action? RelativePositionsUpdated;

        private ILogger _logger;

        private DateTimeOffset _startupTime;

        /// <summary>
        /// Creates an instance of an <see cref="AisManager"/>
        /// </summary>
        /// <param name="interfaceName">Name of the manager, used for message routing</param>
        /// <param name="ownMmsi">The MMSI of the own ship</param>
        /// <param name="ownShipName">The name of the own ship</param>
        public AisManager(string interfaceName, uint ownMmsi, string ownShipName)
            : this(interfaceName, false, ownMmsi, ownShipName)
        {
        }

        /// <summary>
        /// Creates an instance of an <see cref="AisManager"/> using an internal positon provider.
        /// </summary>
        /// <param name="interfaceName">Name of the manager, used for message routing</param>
        /// <param name="throwOnUnknownMessage">True if an exception should be thrown when parsing an unknown message type. This parameter
        /// is mainly intended for test scenarios where a data stream should be scanned for rare messages</param>
        /// <param name="ownMmsi">The MMSI of the own ship</param>
        /// <param name="ownShipName">The name of the own ship</param>
        /// <remarks>
        /// For the position update to work, the AisManager must be externally fed with parsed NMEA sentences.
        /// Raw position sequences are not sufficient.
        /// </remarks>
        public AisManager(string interfaceName, bool throwOnUnknownMessage, uint ownMmsi, string ownShipName)
            : this(interfaceName, throwOnUnknownMessage, ownMmsi, ownShipName, null)
        {
        }

        /// <summary>
        /// Creates an instance of an <see cref="AisManager"/>
        /// </summary>
        /// <param name="interfaceName">Name of the manager, used for message routing</param>
        /// <param name="throwOnUnknownMessage">True if an exception should be thrown when parsing an unknown message type. This parameter
        /// is mainly intended for test scenarios where a data stream should be scanned for rare messages</param>
        /// <param name="ownMmsi">The MMSI of the own ship</param>
        /// <param name="ownShipName">The name of the own ship</param>
        /// <param name="externalPositionProvider">External position provider (e.g. the one from a <see cref="MessageRouter"/>)</param>
        public AisManager(string interfaceName, bool throwOnUnknownMessage, uint ownMmsi, string ownShipName,
            PositionProvider? externalPositionProvider)
            : base(interfaceName)
        {
            _logger = this.GetCurrentClassLogger();
            OwnMmsi = ownMmsi;
            OwnShipName = ownShipName;
            _throwOnUnknownMessage = throwOnUnknownMessage;
            _aisParser = new AisParser(throwOnUnknownMessage);
            if (externalPositionProvider == null)
            {
                _cache = new SentenceCache(this);
                _positionProvider = new PositionProvider(_cache);
            }
            else
            {
                _positionProvider = externalPositionProvider;
            }

            _targets = new ConcurrentDictionary<uint, AisTarget>();
            _lock = new object();
            _activeWarnings = new ConcurrentDictionary<AisMessageId, (string Message, DateTimeOffset TimeStamp)>();
            AutoSendWarnings = true;
            _lastCleanupCheck = null;
            _aisAlarmsEnabled = false;
            _startupTime = DateTimeOffset.UtcNow;
            TrackEstimationParameters = new TrackEstimationParameters();
        }

        /// <summary>
        /// The own MMSI
        /// </summary>
        public uint OwnMmsi { get; }

        /// <summary>
        /// The name of the own ship
        /// </summary>
        public string OwnShipName { get; }

        /// <summary>
        /// Distance from GPS receiver to bow of own ship, see <see cref="Ship.DimensionToBow"/>
        /// </summary>
        public Length DimensionToBow { get; set; }

        /// <summary>
        /// Distance from GPS receiver to stern of own ship, see <see cref="Ship.DimensionToStern"/>
        /// </summary>
        public Length DimensionToStern { get; set; }

        /// <summary>
        /// Distance from GPS receiver to Port of own ship, see <see cref="Ship.DimensionToPort"/>
        /// </summary>
        public Length DimensionToPort { get; set; }

        /// <summary>
        /// Distance from GPS receiver to Starboard of own ship, see <see cref="Ship.DimensionToStarboard"/>
        /// </summary>
        public Length DimensionToStarboard { get; set; }

        /// <summary>
        /// True to have the component automatically generate warning broadcast messages (when in collision range, or when seeing something unexpected,
        /// such as an AIS-Sart target)
        /// </summary>
        public bool AutoSendWarnings { get; set; }

        /// <summary>
        /// Set to the name of a GNSS source to prefer this for getting the current position.
        /// Will fall back to all if not successful (and alternatives are available)
        /// </summary>
        public string? PreferredPositionSource { get; set; }

        /// <summary>
        /// Set of parameters that control track estimation.
        /// </summary>
        public TrackEstimationParameters TrackEstimationParameters { get; private set; }

        /// <summary>
        /// Which <see cref="SentenceId"/> generated AIS messages should get. Meaningful values are <see cref="SentenceId.Vdm"/> or <see cref="SentenceId.Vdo"/>.
        /// Default is "VDO"
        /// </summary>
        public SentenceId GeneratedSentencesId
        {
            get
            {
                return _aisParser.GeneratedSentencesId;
            }
            set
            {
                _aisParser.GeneratedSentencesId = value;
            }
        }

        /// <summary>
        /// Provides access to the positioning provider for the AIS manager
        /// </summary>
        public PositionProvider PositionProvider => _positionProvider;

        /// <summary>
        /// Gets the data of the own ship (including position and movement vectors) as a ship structure.
        /// </summary>
        /// <param name="ownShip">Receives the data about the own ship</param>
        /// <returns>True in case of success, false if relevant data is outdated or missing. Returns false if the
        /// last received position message is older than <see cref="TrackEstimationParameters.MaximumPositionAge"/>.</returns>
        public bool GetOwnShipData(out Ship ownShip)
        {
            return GetOwnShipData(out ownShip, DateTimeOffset.UtcNow);
        }

        /// <summary>
        /// Gets the data of the own ship (including position and movement vectors) as a ship structure.
        /// </summary>
        /// <param name="ownShip">Receives the data about the own ship</param>
        /// <param name="currentTime">The current time</param>
        /// <returns>True in case of success, false if relevant data is outdated or missing. Returns false if the
        /// last received position message is older than <see cref="TrackEstimationParameters.MaximumPositionAge"/>.</returns>
        public bool GetOwnShipData(out Ship ownShip, DateTimeOffset currentTime)
        {
            var s = new Ship(OwnMmsi);
            s.Name = OwnShipName;
            s.DimensionToBow = DimensionToBow;
            s.DimensionToStern = DimensionToStern;
            s.DimensionToPort = DimensionToPort;
            s.DimensionToStarboard = DimensionToStarboard;
            // Try the preferred position source first (might already be null though)
            if (!_positionProvider.TryGetCurrentPosition(out var position, PreferredPositionSource, false,
                    out var track, out var sog, out var heading,
                    out var messageTime, currentTime) ||
                (messageTime + TrackEstimationParameters.MaximumPositionAge) < currentTime)
            {
                // then use any source
                if (PreferredPositionSource == null || !_positionProvider.TryGetCurrentPosition(out position, null,
                        false,
                        out track, out sog, out heading,
                        out messageTime, currentTime) ||
                    (messageTime + TrackEstimationParameters.MaximumPositionAge) < currentTime)
                {
                    s.Position = position ?? new GeographicPosition();

                    s.CourseOverGround = track;
                    s.SpeedOverGround = sog;
                    s.TrueHeading = heading;
                    s.LastSeen = messageTime;
                    ownShip = s;
                    _logger.LogWarning("AISManager: No position for own ship");
                    return false;
                }
            }

            s.Position = position!;
            s.CourseOverGround = track;
            s.SpeedOverGround = sog;
            s.TrueHeading = heading;
            s.LastSeen = messageTime;

            _logger.LogInformation($"AISManager: Position of own ship: {position}, speed: {sog}, course {track}");
            ownShip = s;
            return true;
        }

        /// <inheritdoc />
        public override void StartDecode()
        {
        }

        /// <summary>
        /// Tries to retrieve the target with the given MMSI from the database
        /// </summary>
        /// <param name="mmsi">MMSI to query</param>
        /// <param name="target">Returns the given target, if found. The target should be cast to a more concrete type</param>
        /// <returns>True if the target was found, false otherwise</returns>
        public bool TryGetTarget(uint mmsi,
#if NET5_0_OR_GREATER
            [NotNullWhen(true)]
#endif
            out AisTarget target)
        {
            lock (_lock)
            {
                AisTarget? tgt;
                bool ret = _targets.TryGetValue(mmsi, out tgt);
                target = tgt!;
                return ret;
            }
        }

        private Ship GetOrCreateShip(uint mmsi, AisTransceiverClass transceiverClass, DateTimeOffset? lastSeenTime)
        {
            lock (_lock)
            {
                var ship = GetOrCreateTarget<Ship>(mmsi, x => new Ship(x), lastSeenTime);

                // The transceiver type is derived from the message type (a PositionReportClassA message is obviously only sent by class A equipment)
                if (transceiverClass != AisTransceiverClass.Unknown)
                {
                    ship.TransceiverClass = transceiverClass;
                }

                return ship!;
            }
        }

        private T GetOrCreateTarget<T>(uint mmsi, Func<uint, T> constructor, DateTimeOffset? lastSeenTime)
            where T : AisTarget
        {
            lock (_lock)
            {
                AisTarget? target;
                T ship;
                if (TryGetTarget(mmsi, out target) && target is T targetAsT)
                {
                    ship = targetAsT;
                }
                else
                {
                    // Remove the existing key (this is for the rare case where the same MMSI suddenly changes type from ship to base station or similar.
                    // That should not normally happen, but we need to be robust about it.
                    _targets.TryRemove(mmsi, out _);
                    ship = constructor(mmsi);
                    _targets.TryAdd(mmsi, ship);
                }

                if (lastSeenTime.HasValue)
                {
                    ship.LastSeen = lastSeenTime.Value;
                }

                // Remove any "Vessel lost" messages about this target - it cannot be lost at this point
                var obsoleteWarnings =
                    _activeWarnings.Where(x => x.Key.Mmsi == mmsi && x.Key.Type == AisWarningType.VesselLost);
                foreach (var obsoleteWarning in obsoleteWarnings)
                {
                    _activeWarnings.TryRemove(obsoleteWarning.Key, out _);
                }

                return ship;
            }
        }

        private BaseStation GetOrCreateBaseStation(uint mmsi, AisTransceiverClass transceiverClass,
            DateTimeOffset? lastSeenTime)
        {
            return GetOrCreateTarget<BaseStation>(mmsi, x => new BaseStation(mmsi), lastSeenTime);
        }

        private SarAircraft GetOrCreateSarAircraft(uint mmsi, DateTimeOffset? lastSeenTime)
        {
            return GetOrCreateTarget<SarAircraft>(mmsi, x => new SarAircraft(mmsi), lastSeenTime);
        }

        /// <summary>
        /// Gets the list of active targets
        /// </summary>
        /// <returns>An enumeration of all currently tracked targets</returns>
        public IEnumerable<AisTarget> GetTargets()
        {
            lock (_lock)
            {
                return _targets.Values;
            }
        }

        /// <summary>
        /// Gets the list of targets filtered with the predicate
        /// </summary>
        /// <param name="predicate">A filter method</param>
        /// <returns>The filtered list</returns>
        public IEnumerable<AisTarget> GetTargets(Func<AisTarget, bool> predicate)
        {
            ArgumentNullException.ThrowIfNull(predicate, nameof(predicate));
            lock (_lock)
            {
                return _targets.Values.Where(predicate);
            }
        }

        /// <summary>
        /// Gets the list of all active targets of the given type
        /// </summary>
        /// <typeparam name="T">A type of target, must be a derivative of <see cref="AisTarget"/>.</typeparam>
        /// <returns>An enumeration of all targets of that type</returns>
        public IEnumerable<T> GetSpecificTargets<T>()
            where T : AisTarget
        {
            lock (_lock)
            {
                return _targets.Values.OfType<T>();
            }
        }

        /// <summary>
        /// Processes incoming sequences. Use this method to input an NMEA stream to this component.
        /// Note that _all_ messages should be forwarded to this method, as AIS target tracking requires the position and speed of our own vessel.
        /// </summary>
        /// <param name="source">Message source</param>
        /// <param name="sentence">The new sentence</param>
        public override void SendSentence(NmeaSinkAndSource source, NmeaSentence sentence)
        {
            _cache?.Add(source, sentence);

            DoCleanup(sentence.DateTime);

            AisMessage? msg = _aisParser.Parse(sentence);
            if (msg == null)
            {
                return;
            }

            Ship? ship;
            lock (_lock)
            {
                switch (msg.MessageType)
                {
                    // These contain the same data
                    case AisMessageType.PositionReportClassA:
                    case AisMessageType.PositionReportClassAAssignedSchedule:
                    case AisMessageType.PositionReportClassAResponseToInterrogation:
                    {
                        PositionReportClassAMessageBase msgPos = (PositionReportClassAMessageBase)msg;
                        ship = GetOrCreateShip(msgPos.Mmsi, msg.TransceiverType, sentence.DateTime);
                        PositionReportClassAToShip(ship, msgPos);

                        CheckIsExceptionalTarget(ship, sentence.DateTime);
                        break;
                    }

                    case AisMessageType.StaticDataReport:
                    {
                        // This is the normal static data report from class B transceivers
                        ship = GetOrCreateShip(msg.Mmsi, msg.TransceiverType, null);
                        if (msg is StaticDataReportPartAMessage msgPartA)
                        {
                            ship.Name = msgPartA.ShipName;
                        }
                        else if (msg is StaticDataReportPartBMessage msgPartB)
                        {
                            ship.CallSign = msgPartB.CallSign;
                            ship.ShipType = msgPartB.ShipType;
                            ship.DimensionToBow = Length.FromMeters(msgPartB.DimensionToBow);
                            ship.DimensionToStern = Length.FromMeters(msgPartB.DimensionToStern);
                            ship.DimensionToPort = Length.FromMeters(msgPartB.DimensionToPort);
                            ship.DimensionToStarboard = Length.FromMeters(msgPartB.DimensionToStarboard);
                            ship.NavigationStatus = NavigationStatus.NotDefined;
                        }

                        CheckIsExceptionalTarget(ship, sentence.DateTime);
                        break;
                    }

                    case AisMessageType.StaticAndVoyageRelatedData:
                    {
                        // This message is only sent by class A transceivers.
                        ship = GetOrCreateShip(msg.Mmsi, msg.TransceiverType, null);
                        StaticAndVoyageRelatedDataMessage voyage = (StaticAndVoyageRelatedDataMessage)msg;
                        ship.Name = voyage.ShipName;
                        ship.CallSign = voyage.CallSign;
                        ship.Destination = voyage.Destination;
                        ship.Draught = Length.FromMeters(voyage.Draught);
                        ship.ImoNumber = voyage.ImoNumber;
                        ship.ShipType = voyage.ShipType;
                        var now = DateTimeOffset.UtcNow;
                        if (voyage.IsEtaValid())
                        {
                            int year = now.Year;
                            // If we are supposed to arrive on a month less than the current, this probably means "next year".
                            if (voyage.EtaMonth < now.Month ||
                                (voyage.EtaMonth == now.Month && voyage.EtaDay < now.Day))
                            {
                                year += 1;
                            }

                            try
                            {
                                ship.EstimatedTimeOfArrival = new DateTimeOffset(year, (int)voyage.EtaMonth,
                                    (int)voyage.EtaDay,
                                    (int)voyage.EtaHour, (int)voyage.EtaMinute, 0, TimeSpan.Zero);
                            }
                            catch (Exception x) when (x is ArgumentException || x is ArgumentOutOfRangeException)
                            {
                                // Even when the simple validation above succeeds, the date may still be illegal (e.g. 31 February)
                                ship.EstimatedTimeOfArrival = null;
                            }
                        }
                        else
                        {
                            ship.EstimatedTimeOfArrival = null; // may be deleted by the user
                        }

                        CheckIsExceptionalTarget(ship, sentence.DateTime);
                        break;
                    }

                    case AisMessageType.StandardClassBCsPositionReport:
                    {
                        // This is an alternative static data report for class B transceivers
                        StandardClassBCsPositionReportMessage msgPos = (StandardClassBCsPositionReportMessage)msg;
                        ship = GetOrCreateShip(msgPos.Mmsi, msg.TransceiverType, sentence.DateTime);
                        ship.Position = ValidatePosition(() => new GeographicPosition(msgPos.Latitude, msgPos.Longitude, 0));
                        ship.RateOfTurn = null;
                        if (msgPos.TrueHeading.HasValue)
                        {
                            ship.TrueHeading = Angle.FromDegrees(msgPos.TrueHeading.Value);
                        }
                        else
                        {
                            ship.TrueHeading = null;
                        }

                        ship.CourseOverGround = Angle.FromDegrees(msgPos.CourseOverGround);
                        ship.SpeedOverGround = Speed.FromKnots(msgPos.SpeedOverGround);
                        CheckIsExceptionalTarget(ship, sentence.DateTime);
                        break;
                    }

                    case AisMessageType.ExtendedClassBCsPositionReport:
                    {
                        ExtendedClassBCsPositionReportMessage msgPos = (ExtendedClassBCsPositionReportMessage)msg;
                        ship = GetOrCreateShip(msgPos.Mmsi, msg.TransceiverType, sentence.DateTime);
                        ship.Position = new GeographicPosition(msgPos.Latitude, msgPos.Longitude, 0);
                        ship.RateOfTurn = null;
                        if (msgPos.TrueHeading.HasValue)
                        {
                            ship.TrueHeading = Angle.FromDegrees(msgPos.TrueHeading.Value);
                        }
                        else
                        {
                            ship.TrueHeading = null;
                        }

                        ship.CourseOverGround = Angle.FromDegrees(msgPos.CourseOverGround);
                        ship.SpeedOverGround = Speed.FromKnots(msgPos.SpeedOverGround);
                        ship.DimensionToBow = Length.FromMeters(msgPos.DimensionToBow);
                        ship.DimensionToStern = Length.FromMeters(msgPos.DimensionToStern);
                        ship.DimensionToPort = Length.FromMeters(msgPos.DimensionToPort);
                        ship.DimensionToStarboard = Length.FromMeters(msgPos.DimensionToStarboard);
                        ship.NavigationStatus = NavigationStatus.NotDefined;
                        ship.ShipType = msgPos.ShipType;
                        ship.Name = msgPos.Name;
                        CheckIsExceptionalTarget(ship, sentence.DateTime);
                        break;
                    }

                    case AisMessageType.BaseStationReport:
                    {
                        BaseStationReportMessage rpt = (BaseStationReportMessage)msg;
                        var station = GetOrCreateBaseStation(rpt.Mmsi, rpt.TransceiverType, sentence.DateTime);
                        station.Position = ValidatePosition(() => new GeographicPosition(rpt.Latitude, rpt.Longitude, 0));
                        break;
                    }

                    case AisMessageType.StandardSarAircraftPositionReport:
                    {
                        StandardSarAircraftPositionReportMessage sar = (StandardSarAircraftPositionReportMessage)msg;
                        var sarAircraft = GetOrCreateSarAircraft(sar.Mmsi, sentence.DateTime);
                        // Is the altitude here ellipsoid or geoid? Ships are normally at 0m geoid (unless on a lake, but the AIS system doesn't seem to be designed
                        // for that)
                        sarAircraft.Position = ValidatePosition(() => new GeographicPosition(sar.Latitude, sar.Longitude, sar.Altitude));
                        sarAircraft.CourseOverGround = Angle.FromDegrees(sar.CourseOverGround);
                        sarAircraft.SpeedOverGround = Speed.FromKnots(sar.SpeedOverGround);
                        sarAircraft.RateOfTurn = RotationalSpeed.Zero;
                        break;
                    }

                    case AisMessageType.AidToNavigationReport:
                    {
                        AidToNavigationReportMessage aton = (AidToNavigationReportMessage)msg;
                        var navigationTarget =
                            GetOrCreateTarget(aton.Mmsi, x => new AidToNavigation(x), sentence.DateTime);
                        navigationTarget.Position = ValidatePosition(() => new GeographicPosition(aton.Latitude, aton.Longitude, 0));
                        navigationTarget.Name = aton.Name + aton.NameExtension;
                        navigationTarget.DimensionToBow = Length.FromMeters(aton.DimensionToBow);
                        navigationTarget.DimensionToStern = Length.FromMeters(aton.DimensionToStern);
                        navigationTarget.DimensionToPort = Length.FromMeters(aton.DimensionToPort);
                        navigationTarget.DimensionToStarboard = Length.FromMeters(aton.DimensionToStarboard);
                        navigationTarget.OffPosition = aton.OffPosition;
                        navigationTarget.Virtual = aton.VirtualAid;
                        navigationTarget.NavigationalAidType = aton.NavigationalAidType;
                        break;
                    }

                    case AisMessageType.Interrogation:
                    {
                        // Currently nothing to do with these
                        InterrogationMessage interrogation = (InterrogationMessage)msg;
                        break;
                    }

                    case AisMessageType.DataLinkManagement:
                        // not interesting.
                        break;

                    case AisMessageType.AddressedSafetyRelatedMessage:
                    {
                        AddressedSafetyRelatedMessage addressedSafetyRelatedMessage =
                            (AddressedSafetyRelatedMessage)msg;
                        OnMessage?.Invoke(true, addressedSafetyRelatedMessage.Mmsi,
                            addressedSafetyRelatedMessage.DestinationMmsi, addressedSafetyRelatedMessage.Text);
                        break;
                    }

                    case AisMessageType.SafetyRelatedBroadcastMessage:
                    {
                        SafetyRelatedBroadcastMessage broadcastMessage = (SafetyRelatedBroadcastMessage)msg;
                        OnMessage?.Invoke(true, broadcastMessage.Mmsi, 0, broadcastMessage.Text);
                        break;
                    }

                    default:
                        if (_throwOnUnknownMessage)
                        {
                            throw new NotSupportedException(
                                $"Received a message of type {msg.MessageType} which was not handled");
                        }

                        break;
                }
            }
        }

        private GeographicPosition ValidatePosition(Func<GeographicPosition> attempt)
        {
            try
            {
                return attempt();
            }
            catch (Exception e) when (e is ArgumentException || e is ArgumentOutOfRangeException)
            {
                _logger.LogError($"Invalid position received: {e.Message}");
                return new GeographicPosition();
            }
        }

        internal void PositionReportClassAToShip(Ship ship, PositionReportClassAMessageBase positionReport)
        {
            ship.Position = ValidatePosition(() => new GeographicPosition(positionReport.Latitude, positionReport.Longitude, 0));
            if (positionReport.RateOfTurn.HasValue)
            {
                // See the cheat sheet at https://gpsd.gitlab.io/gpsd/AIVDM.html
                double v = positionReport.RateOfTurn.Value / 4.733;
                ship.RateOfTurn = RotationalSpeed.FromDegreesPerMinute(Math.Sign(v) * v * v); // Square value, keep sign
            }
            else
            {
                ship.RateOfTurn = null;
            }

            if (positionReport.TrueHeading.HasValue)
            {
                ship.TrueHeading = Angle.FromDegrees(positionReport.TrueHeading.Value);
            }
            else
            {
                ship.TrueHeading = null;
            }

            ship.CourseOverGround = Angle.FromDegrees(positionReport.CourseOverGround);
            ship.SpeedOverGround = Speed.FromKnots(positionReport.SpeedOverGround);
            ship.NavigationStatus = positionReport.NavigationStatus;
        }

        private void CheckIsExceptionalTarget(Ship ship, DateTimeOffset now)
        {
            void SendMessage(Ship ship1, string type)
            {
                GetOwnShipData(out Ship ownShip);
                Length distance = ownShip.DistanceTo(ship1);
                SendWarningMessage(new AisMessageId(AisWarningType.ExceptionalTargetSeen, ship1.Mmsi), ship1.Mmsi,
                    $"{type} Target activated: MMSI {ship1.Mmsi} in Position {ship1.Position:M1N M1E}! Distance {distance}",
                    now, ship1, true);
            }

            if (AutoSendWarnings == false)
            {
                return;
            }

            if (ship.NavigationStatus == NavigationStatus.AisSartIsActive)
            {
                SendMessage(ship, "AIS SART status");
            }

            MmsiType type = ship.IdentifyMmsiType();
            switch (type)
            {
                case MmsiType.AisSart:
                    SendMessage(ship, "AIS SART");
                    break;
                case MmsiType.Epirb:
                    SendMessage(ship, "EPIRB");
                    break;
                case MmsiType.Mob:
                    SendMessage(ship, "AIS MOB");
                    break;
            }
        }

        /// <summary>
        /// Sends a message with the given <paramref name="messageText"/> as an AIS broadcast message
        /// </summary>
        /// <param name="messageId">Obsolete message identifier - ignored</param>
        /// <param name="sourceMmsi">Source MMSI, can be 0 if irrelevant/unknown</param>
        /// <param name="messageText">The text of the message. Supports only the AIS 6-bit character set.</param>
        /// <returns>True if the message was sent, false otherwise</returns>
        public bool SendWarningMessage(string messageId, uint sourceMmsi, string messageText)
        {
            return SendWarningMessage(new AisMessageId(AisWarningType.UserMessage, sourceMmsi), sourceMmsi, messageText,
                DateTimeOffset.UtcNow, null);
        }

        /// <summary>
        /// Sends a message with the given <paramref name="messageText"/> as an AIS broadcast message
        /// </summary>
        /// <param name="messageId">Identifies the message. Messages with the same ID are only sent once, until the timeout elapses</param>
        /// <param name="sourceMmsi">Source MMSI, can be 0 if irrelevant/unknown</param>
        /// <param name="messageText">The text of the message. Supports only the AIS 6-bit character set.</param>
        /// <param name="target">The AIS target this warning is about</param>
        /// <returns>True if the message was sent, false otherwise</returns>
        public bool SendWarningMessage(AisMessageId messageId, uint sourceMmsi, string messageText, AisTarget? target)
        {
            return SendWarningMessage(messageId, sourceMmsi, messageText, DateTimeOffset.UtcNow, target);
        }

        /// <summary>
        /// Sends a message with the given <paramref name="messageText"/> as an AIS broadcast message
        /// </summary>
        /// <param name="messageId">Identifies the message. Messages with the same ID are only sent once, until the timeout elapses</param>
        /// <param name="sourceMmsi">Source MMSI, can be 0 if irrelevant/unknown</param>
        /// <param name="messageText">The text of the message. Supports only the AIS 6-bit character set.</param>
        /// <param name="now">The current time (to verify the timeout against)</param>
        /// <param name="target">The AIS target this warning is about. May be null for generic messages</param>
        /// <returns>True if the message was sent, false otherwise (sending disabled, repeat timeout not elapsed, etc)</returns>
        public bool SendWarningMessage(AisMessageId messageId, uint sourceMmsi, string messageText, DateTimeOffset now,
            AisTarget? target)
        {
            return SendWarningMessage(messageId, sourceMmsi, messageText, now, target, false);
        }

        /// <summary>
        /// Sends a message with the given <paramref name="messageText"/> as an AIS broadcast message
        /// </summary>
        /// <param name="messageId">Identifies the message. Messages with the same ID are only sent once, until the timeout elapses</param>
        /// <param name="sourceMmsi">Source MMSI, can be 0 if irrelevant/unknown</param>
        /// <param name="messageText">The text of the message. Supports only the AIS 6-bit character set.</param>
        /// <param name="now">The current time (to verify the timeout against)</param>
        /// <param name="target">The AIS target this warning is about. May be null for generic messages</param>
        /// <param name="emergencyMessage">This is a message indicating a possible emergency. It cannot be suppressed</param>
        /// <returns>True if the message was sent, false otherwise (sending disabled, repeat timeout not elapsed, etc)</returns>
        public bool SendWarningMessage(AisMessageId messageId, uint sourceMmsi, string messageText,
            DateTimeOffset now, AisTarget? target, bool emergencyMessage)
        {
            if (TrackEstimationParameters.SuppressAllVesselWarnings && !emergencyMessage)
            {
                return false;
            }

            if (_activeWarnings.TryGetValue(messageId, out var msg))
            {
                if (msg.TimeStamp + TrackEstimationParameters.WarningRepeatTimeout > now)
                {
                    return false;
                }

                if (target != null)
                {
                    if (target.SuppressionTime > now)
                    {
                        return false;
                    }
                }

                _activeWarnings.TryRemove(messageId, out _);
            }

            if (_activeWarnings.TryAdd(messageId, (messageText, now)))
            {
                OnAisWarning?.Invoke(messageId, sourceMmsi, now, messageText, target);
                SendBroadcastMessage(sourceMmsi, messageText);
                return true;
            }

            return false;
        }

        /// <summary>
        /// Send an AIS broadcast message to the NMEA stream (output!)
        /// Some NMEA devices (in particular general-purpose displays) may pick up this information
        /// from the data stream and show the warning to the user.
        /// </summary>
        /// <param name="sourceMmsi">The message source, can be 0</param>
        /// <param name="text">The text. Will be converted to 6-Bit-Ascii (e.g. only capital letters)</param>
        public void SendBroadcastMessage(uint sourceMmsi, string text)
        {
            SafetyRelatedBroadcastMessage msg = new SafetyRelatedBroadcastMessage();
            _logger.LogWarning($"Sending broadcast message from {sourceMmsi}: {text}");
            msg.Mmsi = sourceMmsi;
            msg.Text = text;
            OnMessage?.Invoke(false, sourceMmsi, 0, text);
            List<NmeaSentence> sentences = _aisParser.ToSentences(msg);
            foreach (var s in sentences)
            {
                DispatchSentenceEvents(this, s);
            }
        }

        /// <inheritdoc />
        public override void StopDecode()
        {
            EnableAisAlarms(false, null);
            _activeWarnings.Clear();
        }

        internal PositionReportClassAMessage ShipToPositionReportClassAMessage(Ship ship)
        {
            PositionReportClassAMessage rpt = new PositionReportClassAMessage();
            rpt.Mmsi = ship.Mmsi;
            rpt.SpeedOverGround = ship.SpeedOverGround.Knots;
            if (ship.RateOfTurn.HasValue)
            {
                // Inverse of the formula above
                double v = ship.RateOfTurn.Value.DegreesPerMinute;
                v = Math.Sign(v) * Math.Sqrt(Math.Abs(v));
                v = v * 4.733;
                rpt.RateOfTurn = (int)Math.Round(v);
            }
            else
            {
                rpt.RateOfTurn = null;
            }

            rpt.CourseOverGround = ship.CourseOverGround.Degrees;
            rpt.Latitude = ship.Position.Latitude;
            rpt.Longitude = ship.Position.Longitude;
            rpt.ManeuverIndicator = ManeuverIndicator.NoSpecialManeuver;
            rpt.NavigationStatus = ship.NavigationStatus;
            if (ship.TrueHeading.HasValue)
            {
                rpt.TrueHeading = (uint)ship.TrueHeading.Value.Degrees;
            }

            return rpt;
        }

        /// <summary>
        /// Sends a ship position report for the given ship to the NMEA stream. Useful for testing or simulation.
        /// </summary>
        /// <param name="transceiverClass">Transceiver class to simulate</param>
        /// <param name="ship">The ship whose position data to send</param>
        /// <returns></returns>
        /// <exception cref="InvalidOperationException">An internal inconsistency occurred</exception>
        /// <exception cref="NotSupportedException">This message type is not currently supported for encoding</exception>
        public NmeaSentence SendShipPositionReport(AisTransceiverClass transceiverClass, Ship ship)
        {
            if (transceiverClass == AisTransceiverClass.A)
            {
                PositionReportClassAMessage msg = ShipToPositionReportClassAMessage(ship);
                List<NmeaSentence> sentences = _aisParser.ToSentences(msg);
                if (sentences.Count != 1)
                {
                    throw new InvalidOperationException(
                        $"Encoding the position report for class A returned {sentences.Count} sentences. Exactly 1 expected");
                }

                NmeaSentence single = sentences.Single();

                DispatchSentenceEvents(this, single);
                return single;
            }
            else
            {
                throw new NotSupportedException("Only class A messages can currently be constructed");
            }
        }

        /// <summary>
        /// Regularly scan our database to check for outdated targets. This is done from
        /// the parser thread, so we don't need to create a separate thread just for this.
        /// </summary>
        /// <param name="currentTime">The time of the last packet</param>
        private void DoCleanup(DateTimeOffset currentTime)
        {
            if (TrackEstimationParameters.DeleteTargetAfterTimeout <= TimeSpan.Zero)
            {
                return;
            }

            // Do if the cleanuplatency has elapsed
            if (_lastCleanupCheck == null || _lastCleanupCheck.Value + TrackEstimationParameters.CleanupLatency < currentTime)
            {
                lock (_lock)
                {
                    foreach (var t in _targets.Values)
                    {
                        if (t.Age(currentTime) > TrackEstimationParameters.DeleteTargetAfterTimeout)
                        {
                            _targets.TryRemove(t.Mmsi, out _);
                        }
                    }

                    _lastCleanupCheck = currentTime;
                }
            }
        }

        /// <summary>
        /// Gets the target with the given MMSI
        /// </summary>
        /// <param name="mmsi">The MMSI to search</param>
        /// <returns>The given target or null if it was not found.</returns>
        public AisTarget? GetTarget(uint mmsi)
        {
            lock (_lock)
            {
                return _targets.Values.FirstOrDefault(x => x.Mmsi == mmsi);
            }
        }

        /// <summary>
        /// Enable automatic generation of AIS alarms.
        /// This method will start a background thread that regularly evaluates all ships in vicinity for possibly dangerous proximity.
        /// It uses an estimate of a track for each ship to find the closest point of approach (CPA) and the time to that closest point (TCPA).
        /// When this is enabled, <see cref="AisTarget.RelativePosition"/> will be regularly updated for all targets.
        /// </summary>
        /// <param name="enable">True to enable AIS alarms. The alarms will be presented by a message on the outgoing stream and a call to <see cref="OnMessage"/></param>
        /// <param name="parameters">Parameter set to use for the estimation</param>
        /// <remarks>Note 1: Since this uses a precise track estimation that includes COG change, the calculation is rather expensive. CPU
        /// performance should be monitored when in a crowded area. Algorithm improvements that cut CPU usage e.g. for stationary ships are pending.
        /// Note 2: The algorithm is experimental and should not be relied on.
        /// Also read the notes at <see cref="AisManager"/>
        /// </remarks>
        public void EnableAisAlarms(bool enable, TrackEstimationParameters? parameters = null)
        {
            _aisAlarmsEnabled = enable;
            if (parameters != null)
            {
                TrackEstimationParameters = parameters;
            }

            if (enable)
            {
                var t = _aisBackgroundThread;
                if (t != null && t.IsAlive)
                {
                    return;
                }

                t = new Thread(AisAlarmThread);
                t.Start();
                _aisBackgroundThread = t;
            }
            else
            {
                var t = _aisBackgroundThread;
                if (t != null)
                {
                    t.Join();
                    _aisBackgroundThread = null;
                }
            }
        }

        /// <summary>
        /// Clears the list of suppressed warnings
        /// </summary>
        public void ClearWarnings()
        {
            _activeWarnings.Clear();
        }

        /// <summary>
        /// This thread calculates CPA and TCPA between vessels and generates corresponding warnings.
        /// </summary>
        private void AisAlarmThread()
        {
            Stopwatch sw = new Stopwatch();
            // This uses a do-while for easier testability
            do
            {
                sw.Restart();
                AisAlarmThreadOperation(DateTimeOffset.UtcNow);
                // Restart the loop every check interval if the current interval used less time than allocated.
                // We always wait at least 20ms, so that we don't fully block the CPU (even thought that could be really a small amount)
                if (TrackEstimationParameters.AisSafetyCheckInterval > TimeSpan.Zero)
                {
                    TimeSpan remaining = TrackEstimationParameters.AisSafetyCheckInterval - sw.Elapsed;
                    if (remaining < TimeSpan.FromMilliseconds(20))
                    {
                        remaining = TimeSpan.FromMilliseconds(20);
                    }

                    Thread.Sleep(remaining);
                }
            }
            while (_aisAlarmsEnabled);
        }

        /// <summary>
        /// Calculate TCPA and CPA for all vessels in range.
        /// </summary>
        /// <param name="time">The current time (provide externally in case we're replaying a recorded log)</param>
        internal void AisAlarmThreadOperation(DateTimeOffset time)
        {
            Ship ownShip;
            if (GetOwnShipData(out ownShip, time) == false)
            {
                // Only emit this warning if we didn't just start the application.
                // That message gets otherwise always triggered at startup, which is annoying.
                if (TrackEstimationParameters.WarnIfGnssMissing && (_startupTime - time).Duration() > TimeSpan.FromMinutes(1))
                {
                    SendWarningMessage(new AisMessageId(AisWarningType.NoGnss, ownShip.Mmsi), ownShip.Mmsi, "No GNSS data or GNSS fix lost", null);
                }

                Thread.Sleep(TrackEstimationParameters.AisSafetyCheckInterval);
                goto nextloop;
            }

            // it's a ConcurrentDictionary, so iterating over it without a lock is fine
            List<ShipRelativePosition> differences =
                ownShip.RelativePositionsTo(_targets.Values, time, TrackEstimationParameters);

            foreach (var difference in differences)
            {
                string name = difference.To.NameOrMssi();
                Length? cpa = difference.ClosestPointOfApproach;
                TimeSpan? tcpa = difference.TimeToClosestPointOfApproach(time);
                if (cpa.HasValue && tcpa.HasValue)
                {
                    if (difference.SafetyState == AisSafetyState.Dangerous)
                    {
                        // Warn if the ship will be closer than the warning distance in less than the WarningTime
                        SendWarningMessage(new AisMessageId(AisWarningType.DangerousVessel, difference.To.Mmsi), difference.To.Mmsi,
                            $"{difference.To.NameOrMssi()} CPA {cpa.Value.NauticalMiles:F2}; TCPA {tcpa.Value:mm\\:ss}",
                            time, difference.To);
                    }

                    if (difference.SafetyState == AisSafetyState.Lost &&
                        WarnAboutLostTarget(difference))
                    {
                        // The vessel was lost
                        SendWarningMessage(new AisMessageId(AisWarningType.VesselLost, difference.To.Mmsi), difference.To.Mmsi,
                            $"{difference.To.NameOrMssi()} LOST: CPA {cpa.Value.NauticalMiles:F2}; TCPA {tcpa.Value:mm\\:ss}",
                            time, difference.To);
                    }
                }
            }

            lock (_lock)
            {
                // Separate loop, because this one requires a lock and is cheaper than the above (sending a message may be expensive and could potentially be recursive)
                foreach (var difference in differences)
                {
                    // Good we keep the target ship in the type, otherwise this would require an O(n^2) iteration
                    difference.To.RelativePosition = difference;
                }
            }

            nextloop:
            LogCurrentState(time);
            RelativePositionsUpdated?.Invoke();
        }

        private void LogCurrentState(DateTimeOffset now)
        {
            List<AisTarget> targets;
            lock (_lock)
            {
                targets = GetTargets().ToList();
            }

            foreach (var target in targets)
            {
                _logger.LogDebug($"{target.NameOrMssi()}: Last known position {target.Position} at {target.LastSeen:T}");
                var rel = target.RelativePosition;
                if (rel != null)
                {
                    var cpa = rel.ClosestPointOfApproach.GetValueOrDefault();
                    var tcpa = rel.TimeToClosestPointOfApproach(now).GetValueOrDefault();
                    _logger.LogDebug($"MMSI {target.Mmsi}. Distance {rel.Distance}, Bearing {rel.Bearing}, CPA: {cpa.NauticalMiles}nm, TCPA:{tcpa:g}");
                }
            }
        }

        private bool WarnAboutLostTarget(ShipRelativePosition difference)
        {
            if (difference.Distance < TrackEstimationParameters.VesselLostWarningRange)
            {
                if (difference.To is MovingTarget mvt && TrackEstimationParameters.VesselLostMinSpeed.HasValue)
                {
                    // if it's a moving target and we have a value for the min speed, only warn if the vessel was faster
                    if (mvt.SpeedOverGround > TrackEstimationParameters.VesselLostMinSpeed.Value)
                    {
                        return true;
                    }

                    return false;
                }

                // If not a moving target, always warn when it was in range (these are AToN targets and similar, they're
                // not supposed to fail)
                return true;
            }

            // if not within warning range, never warn
            return false;
        }
    }
}
